Skip to content

feat: add React Native UIKit runtime primitives#46

Draft
DjDeveloperr wants to merge 124 commits into
refactorfrom
codex/rn-module-fabric-turbomodule-worklets
Draft

feat: add React Native UIKit runtime primitives#46
DjDeveloperr wants to merge 124 commits into
refactorfrom
codex/rn-module-fabric-turbomodule-worklets

Conversation

@DjDeveloperr

@DjDeveloperr DjDeveloperr commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Generic React Native UIKit / Fabric / worklet runtime primitives in @nativescript/react-native, plus the @nativescript/react-native-screens package built on top of them — a thin (~7k-line, single-file) NativeScript-interop engine that drives a real UIKit UINavigationController and is consumed by stock, unforked @react-navigation/native-stack.

Runtime primitives: RN Native API install isolated from aggregate global symbols; callback-lifetime policy for RN/worklet runtimes; UIKit host lifecycle, transaction, touch/hit-test, target/action, and Fabric layout-trait support.

What changed (architecture)

  • Retired the earlier ~45k-line in-fork react-native-screens port (device-buggy). Replaced by the thin packages/react-native-screens adapter in this repo, consumed by unmodified react-navigation (the fork is gone; only a one-line peer-dep is added to native-stack).
  • Runtime interop fixes (regressions the thin adapter surfaced): inherited-ObjC-selector resolution on ClassBuilder subclasses (5806956f); Fabric content mounting into no-lifecycle-callback hosts (fc9dbe6f); Yoga layout for hosted subviews no longer poisoning Fabric's cache (ca7018e5); skip Auto-Layout-owned subviews in the detached-children fill pass (059c8a8e).
  • Parity landed: descriptor + React-element header items (left/right/custom title, as UIBarButtonItem.customView/titleView), header styling (fonts/colors/blur/RTL/dark), ScreenFooter, FullWindowOverlay, two-sided + initial lifecycle events, onHeaderHeightChange, react-freeze, tabs re-snapshot, animation subset (none, modal fade/flip, replaceAnimation).
  • Custom stackAnimation animators (new): the animated variants are served by an engine-owned UIViewControllerAnimatedTransitioning vended from the navigation delegate, with a bounded force-complete watchdog and a full-width interactive pan (UIKit's own edge recognizer is bound to the native transition, so a custom-animated screen needs its own). animationControllerForOperation: returns nil for default / slide_from_right / ios_from_* / none / flip, so default navigation and its stock edge back-swipe are untouched — asserted, see below.
  • SearchBar (new, landed): headerSearchBarOptions / <SearchBar> build a real UISearchController on navigationItem.searchController (descriptor path, mirroring upstream updateSearchController), with focus/blur/change/submit/cancel events and the focus/blur/clear/setText/cancel commands. The abort that held this back was a bogus selector name — see below.
  • statusBar / orientation window traits (new, landed): statusBarStyle / statusBarHidden / statusBarAnimation / screenOrientation / homeIndicatorHidden / hideKeyboardOnSwipe on the visible screen. Requires UIViewControllerBasedStatusBarAppearance = YES in the app's Info.plist (same as upstream).
  • In progress: content-mount timing, page-sheet modal + sheets/detents.

Test net (host-side jest)

  • React Navigation native-stack suite: 19/19 against this package — identical to 19/19 against upstream react-native-screens@4.25.2, i.e. the native-stack contract is satisfied with zero regression vs upstream.
  • Package unit + surface suites: 97/97 (62 + 17 search bar + 18 window traits). react-navigation core/routers/elements canaries: 684/684.
  • Commands (from packages/react-native-screens): npm test, npm run test:rnav.

Verification

  • runtime: for f in packages/react-native/test/*.test.js; do node "$f" || exit 1; done; npm run check:ffi-boundaries
  • on-simulator: asserting integration harness (itest) + interleaved splash-stratified measurement rig for latency/blank metrics.

On-simulator itest (iPhone 17 / iOS 26, Release build, cold-launch deep link, no Metro)

cd test/screens-itest && ./itest run --suite core8/8 green: nav-stack, modal,
header-native, header-react, footer, overlay, tabs, back-swipe. Each scenario drives itself
from JS and is cross-checked host-side against the accessibility tree.

./itest run --suite parity5/5 green, gating the animator work plus the two features
that were previously parked as xfail:

  • anim-variants — a mid-screen (non-edge) drag pops a fade screen but does nothing on a
    none screen or on a default-animation screen, and the stock edge drag still pops the
    default one. The engine offers its full-width pan only for screens it hands to a custom
    animator, so those four together are a decision procedure for which variants take that path —
    and the last two are the regression guard that default navigation never entered it.

  • anim-interactive — the full-width pan pops a custom-animator screen.

  • anim-frames — captures frames of a 6s fade push in flight. A mid-fade frame (incoming
    screen partly transparent over the outgoing one) is something neither UIKit's own push nor a
    completed transition can produce, so it is direct evidence both that the custom animator is
    driving the pixels and that animationDuration feeds it.

  • searchbar — pushes the search screen, proves a real UISearchController was assigned (the
    search node exists in the FULL accessibility tree), taps the field and types: the app must
    observe onChangeText with the typed text (on device: changes:7,text:nsrocks,focus:1).

  • statusbar — pushes each window-trait screen, parks for a frame, and the host asserts the
    pixels of the status bar (FRAME_CHECKS + a minimal PNG reader in lib/png.js): the bar
    is system-drawn, so it is invisible to both JS and the accessibility tree. Every style is
    paired with a contradicting background so iOS's own contrast adaptation cannot produce a
    pass — white glyphs over a light background, dark glyphs over a dark one, an empty band when
    hidden, and the default restored after the pop.

Note on why the animator scenarios are behavioral rather than timed: measuring the push and checking it tracks
animationDuration does not work here — react-navigation's transitionStart/transitionEnd
pair is delivered through the engine's reconcile and JS hops, and measured on-device those come
out indistinguishable (default 833ms, fade(1800) 621ms, none 708ms,
slide_from_bottom(900) 734ms).

Fixed since the last review — both features now merged and gated

  • SearchBar — focusing the bar aborted the app with Objective-C selector is not available: setShowsCancelButton (from searchBarTextDidBeginEditing). The engine called
    setShowsCancelButton(flag, animated) behind a typeof guard that a NativeScript proxy
    satisfies for a selector it cannot dispatch; the real selector is
    -[UISearchBar setShowsCancelButton:animated:], bridged as setShowsCancelButtonAnimated.
    All 63 typeof-guarded selectors in the engine were audited against the UIKit/Foundation
    metadata — the only other name with no real counterpart was a dead subview.pointInside(…)
    fallback in the FullWindowOverlay hit-test (real name pointInsideWithEvent), now removed.
    Gated by the asserting searchbar scenario in --suite parity.
  • statusBar window traits — two independent defects. (1) The overrides were passed to
    __extendClass as plain function values, but every trait is a metadata property, and only
    an accessor descriptor binds a property override (methodOverridesForName skips every
    member.property) — so the extended class carried no overrides at all. (2) Nothing forwarded
    UIKit's query down to the screen: with UIViewControllerBasedStatusBarAppearance = YES iOS
    asks the key window's root controller and walks only childViewControllerFor…, which
    neither a UINavigationController nor RN's root (a bare [UIViewController new]) answers.
    Upstream fixes this natively by swizzling those getters on UIViewController process-wide
    (ios/UIViewController+RNScreens.mm); this JS-only adapter builds a forwarding subclass with
    __extendClass and re-classes the window root with object_setClass (the subclass adds no
    ivars, so the instance layout is unchanged), while engine-owned navigation controllers — the
    stack's and a presented modal's — are built from that subclass directly. Gated by the
    pixel-asserting statusbar scenario in --suite parity.

Known-broken, parked as xfail

Each of these has a fully written asserting scenario in ./itest run --suite xfail, so it
turns green the moment the behavior is fixed:

  • slide_from_bottom (a variant of the animator work that is merged) — a push
    sometimes lands with blank content: route stack and nav-bar title correct, content area
    empty. Reproduced 3/3 when the push followed an earlier push/pop in the same launch, and
    mounts correctly when it is the first push of a cold launch — so this looks like the port's
    known content-mount timing rather than anything specific to that animator (fade / none
    drive the same probe screen). Parked as xfail because an order-dependent scenario cannot
    gate.

Update — push-latency win + install-path consolidation (f32d99d2, 53c16b30)

Two follow-ups, each measurement-grounded and gate-clean (unit 106/106, test:rnav 19/19, itest core 8/8, parity 5/5):

  • Dropped the dead push content-wait (f32d99d2). shouldDelayPushUntilControllerHasContent
    held every animated push back for two reconciles unconditionally, then consulted a probe
    (controllerHasReactContent) that answers true for any non-hidden subview at depth > 0 — so it
    never once gated a real push. Instrumented over cold cycles, t(contentReady) − t(push) was
    negative on all measurable pushes (content is hosted before the push), invalidating the
    "content lands ~270ms after the push" model the wait was written against. Interleaved A/B (JS
    navigate() → UIKit viewControllers mutation, same demo build, 12 cold pairs): with the
    wait 2/12 cycles installed the push at all (the other 10 dropped it, the screen limping in via
    the non-animated fallback tail); without it 12/12 installed via the animated
    pushViewControllerAnimated branch (median ~635ms navigate→install). No blank replaced it —
    frame-exact scoring read CUM detail-blank 0.0 on both builds. controllerHasReactContent is
    kept for the modal-present path, where content genuinely lands after presentation. This is also
    the root cause of the earlier "50/58 first navigations never animate" observation: it was the
    content-wait pre-empting the push, not a classification gap.

  • applyStackModel install funnel (53c16b30). Every viewControllers mutation and all
    model bookkeeping now flow through one worklet. Seven install sites (reconcile, dismiss-repair,
    transition-fallback, modal-dismiss-restore, modal-base-sync, modal-sync, did-show-fallback)
    collapse to a single classifier; recordStackModel is the sole writer of
    stackNativeKeys/Counts (replacing ~13 scattered pairs) and the post-install
    layout → configureStackControllers → configureNavigationAppearance → updateNativeBackGesture
    tail (repeated ~5×) collapses to one helper. A new stackModelVersions counter lets
    timer-scheduled installs carry the model version they were armed against as a token; the
    funnel drops a stale-token call unless native still disagrees with the target and no newer model
    superseded it, so the blind re-install timers become no-ops. Behavior-preserving — every
    path issues byte-identical UIKit calls (proven by the unchanged itest core/parity). Net the
    engine file grows ~73 code lines (the funnel's doc block + stale-token machinery); the win is
    structural de-duplication, not raw LOC.

Also fixes a pre-existing itest harness eagerness bug the funnel work surfaced: fillRef tapped
a search-field ref whose rect was captured from the poll snapshot before the UISearchBar's
accessibility frame settled (tap landed at the bar's origin corner instead of the field centre, so
nothing was typed). It now re-resolves the ref from a fresh snapshot immediately before the tap.
Independent of the engine change — the searchbar scenario failed identically on the parent
commit.

  • Modal present/dismiss lifecycle — state machine (a2dee23b). Replaces the modal boolean
    sprawl (stackModalPresentedModally, stackModalDismissRequestedFromJS) with a coherent,
    epoch-guarded state machine that advances on observed UIKit state
    (presentingViewController / beingPresented / view.window) rather than depending on the
    presentViewController/dismissViewController completion firing — the assumption that defeated
    the earlier scoped patches. Behavior-neutral checkpoint: modal still maps to
    OverFullScreen
    (the mapping flip is a later stage).
    • Registry: stackModalPhase (idle → present-requested → presenting → presented →
      dismiss-requested → dismissing), stackModalEpoch (bumped on every phase entry; every
      completion / observation probe / drain no-ops on epoch mismatch), stackModalQueued (the one
      latest-wins queued dismiss).
    • Present boundary: reconcileStack records the modal desire (from firstModalIndex) above
      the transition gate and arms a coalesced dispatch_async(main) drain; it no longer
      presents/dismisses inline. drainModalStack is the sole caller of
      presentModalStack/dismissModalStack and never mutates viewControllers itself (all
      installs stay in applyStackModel); modalMachineAction is the pure transition table (dismiss
      is issued from exactly one edge).
    • Present gate (hostPresentationBlocked): refuses to present while the host is mid-transition /
      not in a window; parked with no timer re-arm. Liveness = boundary drains from
      finishTransition, did-show, and every reconcile.
    • Observation fan: a bounded fixed one-shot setTimeout fan (never self-re-arming), epoch-guarded
      and read-only, drives observed-presented (stable 2 probes) / observed-gone /
      observed-wedged(never-taken safe-drop); the completions are accelerators only.
    • Deleted the destructive dismiss watchdogs (setTimeout(complete, 420/700) +
      force-hide/removeFromSuperview). Safe teardown: finalize only when the dismiss is observed
      complete; drop (registry/delegate clear + finishTransition(cancelled)) when never taken —
      never reach into the modal view's superview (UIKit owns it).
    • Determining spike (throwaway, reverted before this commit): instrumented ~15 cold cycles ×
      3 presenter identities. Verdict — under the reproducible cadence the present completion does
      fire reliably (15/15 OverFullScreen; every issued present under pageSheet), ~700 ms after
      issue, with the presented controller attaching to the window at ~101 ms; none of the
      "completion lost / never completed / never taken" cases reproduce. The real hazard is a late
      completion racing an early dismiss — which is exactly what the machine (queue-a-dismiss +
      advance-on-observed-state) tolerates. All three presenter identities (tab-bar host /
      parentNavigationController / top-most presentedViewController walk) attach identically
      (~101 ms median), so no presenter change is warranted on latency grounds.
    • Gates: unit 120/120 (+14 machine transition-table / epoch-no-op / single-dismiss-issue-point
      tests), test:rnav 19/19, itest --suite core 8/8 (incl. modal, accessibility-asserted
      "Modal" present → Home), itest --suite parity 5/5, and the interleave rig
      end_home_clean 100% (12/12) on the machine vs 100% on the 53c16b30 control (stalled
      0/10) — proof the refactor did not break the working modal.

Modal lifecycle — Stages 3–5: real page-sheet modals now land (2f58bb26, 49460b5c, dda9e2c1)

Building on the observed-state modal machine (Stage 2), presentation:'modal' / 'pageSheet' / 'formSheet' now present as real UIKit sheets that survive rapid present→dismiss.

  • Stage 3 — native content readiness (2f58bb26). Retired the false-positive controllerHasReactContent view probe from the modal present gate (the bare ActivityView wrapper chain always satisfied it). The modal present edge now waits on real native readiness: the screen controller gains a guarded hostReady handler that sets screenContentReady[screenId], modal screens carry emitOffWindowHostReady (so the signal reaches them before they are presented), and the gate consults it through the pure modalPresentReadinessAction — present when ready, else HOLD for exactly one reconcile of grace then present regardless (a bounded deadline, never the unbounded hold that deadlocks).
  • Stage 4 — real sheet mapping (49460b5c). modal → UIModalPresentationAutomatic (page sheet on iPhone), pageSheet → PageSheet, formSheet → FormSheet, plus the iOS-18 prefersPageSizing restore. fullScreenModal deliberately stays OverFullScreen (true FullScreen detaches the RN root surface and kills touch). What makes the sheet survive the rapid cadence that reverted 60924dac is the machine, not a watchdog: the hostPresentationBlocked drain gate presents only from an idle boundary, an early dismiss is CANCELLED (present-requested) or QUEUED latest-wins (presenting), and teardown runs only against an observed-completed presentation.
  • Stage 5 — the non-vacuous itest gate (dda9e2c1). Three new core scenarios: page-sheet-visible (presented view INSET from the top and content present in two frames ≥300ms apart — distinguishes a real sheet from both OverFullScreen and the old stranded-blank failure), swipe-down-syncs (a swipe-down dismisses the sheet and collapses JS routes to [Home], and a gestureEnabled:false modal SURVIVES the identical swipe), and rapid-present-dismiss (present→dismiss at 200/300/400ms ×3, clean [Home] after each — the race that stranded 60924dac). The existing modal scenario now asserts sheet-inset. A presented modal removes the ProbeOverlay from the a11y tree, so the harness gained host-autonomous auto-capture + auto-swipe (driven off the modal's content label).

Gates (all green, on-sim Release, sim BF759806, no Metro):

  • npm test 123/123 (+3 readiness + updated mapping tests), npm run test:rnav 19/19.
  • itest --suite core 11/11 (all attempt=1, incl. the 3 new scenarios with real frame checks), itest --suite parity 5/5 unregressed.
  • Interleave end_home_clean 27/27 = 100% over 9 rapid present→dismiss launches — the sheet presents AND dismisses cleanly under the rapid cadence (vs 60924dac's 0%).
  • Human-pace parity: the port's page sheet is pixel-identical to upstream react-native-screens 4.25.2 (org.nativescript.uikit.demo.original) in every measured band (nav-bar y=78 vs OverFullScreen's 62; top dim-band 198 vs 247), and swipe-down dismisses to a clean Home.

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bde5364d-479b-42f5-8acb-f816e7a893c0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/rn-module-fabric-turbomodule-worklets

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

DjDeveloperr and others added 30 commits July 19, 2026 05:32
…ds the modal

`60924dac` mapped `modal` to `UIModalPresentationAutomatic` and `pageSheet` to
`UIModalPresentationPageSheet`, matching upstream RNS (RNSScreen.mm L231/L245).
Under this engine's present/dismiss race a *sheet* style leaves the modal stuck
on screen permanently, so both fall back to `OverFullScreen` again.

Bisected frame-accurately on the custom-stack demo (Release, sim BF759806),
`end_home_clean` = the app is back on a clean Home when the cycle ends:

  ca7018e  100% (45/45)   cbf946c 100% (10/10)
  60924da    0% (0/7)  <- first bad commit
  8e36c81   38% (17/45)

The race: the modal is presented from the tab-bar host while the base
navigation controller is still mid-transition, so UIKit defers attaching the
presented view ("A transition was started while preempting previous
transition") by 530-1780ms. The route's dismissal lands at ~1.9s, frequently
inside that gap, and UIKit refuses `dismissViewControllerAnimated:` outright
while a presentation is in flight. `dismissModalStack`'s 420/700ms watchdogs
then fire anyway: they pull the navigation controller's view out of UIKit's
presentation container while the container itself stays presented, and clear
`stackModalNavigationControllers[stackId]` so no later dismissal can reach it.
Under a page sheet that is a blank sheet that can never be dismissed
(`end_cdark` still 0.0007 at 16.9s with `REC_SECS=24` — permanent, not slow).
Under `OverFullScreen` the same race is survivable: the modal is merely
invisible and is then torn down, so the app always returns Home.

A parked-dismiss hand-off was tried first and rejected on measurement: park a
dismissal that arrives while `isBeingPresented` and replay it from
`presentViewController`'s own completion block (single hand-off, no re-arming
timer). It drives UIKit's dismiss rejections to zero, but `end_home_clean` only
reached 40% (10/25) on top of `8e36c817`, because the presentation often never
completes within the route's lifetime at all. Deferred presentation is the
thing that has to be fixed; refusing to give up the sheet before then just
trades one stuck state for another.

Kept from `60924dac`: the `UIAdaptivePresentationControllerDelegate` (ITEM 4),
`completeModalDismissalBookkeeping`, and the transparent-presentation
background work (ITEM 5). `8e36c817`'s ITEM C — `preventNativeDismiss` /
`gestureEnabled` / `onGestureCancel` / `onNativeDismissCancelled`,
`modalInPresentation`, `UINavigationItem.backAction` — builds on that delegate
and is unchanged. `presentationControllerShouldDismiss` and
`modalInPresentation` are inert for `modal` now that it is not interactively
dismissible, but still apply to `formSheet` / `containedModal`, and
`backAction` is unaffected.

Forfeited parity, to retry once the presentation deferral is fixed: `modal` as
an inset swipe-dismissible page sheet, `pageSheet` as a real page sheet, and
the iOS 18 `sheetPresentationController.prefersPageSizing` write.

Measured, 25 graded cold Release cycles per build after a 10-cycle host-warmup
skip, both orders on freshly rebooted sims, healthy batches (splash ~1.0-1.3s,
video 5-7MB):

  end_home_clean   this commit 100% (25/25) control-first, 100% (25/25)
                   revert-first  |  8e36c81 28% (7/25)  |  ca7018e 100%
  modal presented  this commit 32-36%  |  8e36c81 28%  |  ca7018e 20-28%

Separately noted, NOT addressed here and NOT caused by this commit: cumulative
Detail-blank (`blank_total_ms` >= 200ms) is 68% on unmodified `8e36c817` and
80-84% here, against 12-20% on `ca7018e5`, consistently in both run orders on a
clean host. An earlier A/B concluded there was no Detail-render regression, but
that run measured the control at 56-60% on a loaded host; on a clean host the
control is 12-20% and every HEAD-derived build is 56-84%. That points at a real
content-mount regression somewhere in `cbf946cc..8e36c81` (react-freeze is the
first suspect) and wants its own bisect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Animation

`stackAnimation` was read nowhere in the engine -- it rode through `...rest`
into `registry.screenProps` and every one of the 11 variants coerced to the
UIKit default. This lands the subset that does not need a custom
`UIViewControllerAnimatedTransitioning`:

- `none` -> non-animated push/pop. Upstream expresses this as a zero-duration
  animator (RNSScreenStackAnimator.mm L60-62); `animated: false` is visually
  identical and cannot leave a frame of movement behind. The pop-path snapshot
  is skipped when non-animated, since it only exists to cover an animation.
  Measured against 3ff911a interleaved: the control slides across ~6 frames at
  30fps, this build swaps Home -> Detail in a single frame.
- modal `fade` / `flip` -> `modalTransitionStyle` (CrossDissolve /
  FlipHorizontal), mirroring `-[RNSScreenView setStackAnimation:]`
  (RNSScreen.mm L290-312). Verified on video: the control covers vertically,
  this build cross-dissolves in place.
- `replaceAnimation` -> the replace is now animated in both directions,
  synthesised the way upstream does (RNSScreenStack.mm L623-654): `push` is a
  plain animated `setViewControllers:`, `pop` seats the new stack with the
  outgoing controller still on top and then pops it, hiding the back button
  when the root is being replaced. Previously every replace took the
  non-animated `setViewControllers` fall-through.

NOT landed, and deliberately so: the five variants that require the stack's
`UINavigationControllerDelegate` to vend an animator from
`navigationController:animationControllerForOperation:...` -- `simple_push`,
`slide_from_left`, `fade`, `slide_from_bottom`, `fade_from_bottom` -- plus
`transitionDuration`, which can only reach UIKit through that animator. A
worklet implementation of `UIViewControllerAnimatedTransitioning` was built
against the upstream curves and measured twice (fully exception-guarded, then
additionally retaining the in-flight `UIViewPropertyAnimator`s the way
upstream's `_inFlightAnimator` does) and both times left the navigation
controller wedged -- blank content, empty navigation bar, no recovery --
whenever the animator's completion failed to reach `completeTransition:`. The
rationale and the two candidate routes forward are recorded above
`modalTransitionStyle`. `ios_from_right` / `ios_from_left` /
`slide_from_right` / `default` correctly resolve to the platform default on
iOS and need no code.

Screens that set no `stackAnimation` are unaffected: `screenDisablesAnimation`
returns false, the push/pop stay animated, and the replace branch cannot fire
on a push or a pop. No `UINavigationControllerDelegate` method was added and
neither `installNativeBackGestureDelegate` nor `updateNativeBackGesture` was
touched, so the interactive pop is unchanged by construction -- confirmed on
device: a full edge swipe pops, a short edge swipe cancels and stays put
(proving UIKit is still tracking the finger), and a cancel followed by a full
swipe pops cleanly with transition events still firing.

Non-regression, Release, interleaved against 3ff911a on a splash-matched
batch: end_home_clean 26/26 across three A/B runs (10/10 on the final pristine
run, splash 1146-1161ms vs control 1128-1190ms), 0 new .ips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, blur, direction, UI style

react-navigation's `useHeaderConfigProps` flattens the navigation theme's
`fonts.bold` / `fonts.heavy` into the header config, so `titleColor`,
`titleFontFamily`, `titleFontSize`, `titleFontWeight` and
`experimental_userInterfaceStyle` arrive on EVERY screen, themed app or not
(native-stack useHeaderConfigProps.tsx L551-554, L560). The engine consumed
none of them, so `headerTitleStyle` and the theme's header fonts were dropped
silently on every app. Closes that gap by mirroring
`+[RNSScreenStackHeaderConfig buildAppearance:withConfig:]`
(react-native-screens ios/RNSScreenStackHeaderConfig.mm L352-455).

Fields closed:
  - titleColor / titleFontFamily / titleFontSize / titleFontWeight
    -> `titleTextAttributes`, gated exactly as upstream so an unstyled screen
    still gets no write at all.
  - largeTitleColor / largeTitleFontFamily / largeTitleFontSize /
    largeTitleFontWeight -> `largeTitleTextAttributes` (kept separate from the
    title attributes, falling back to `titleColor`, as upstream does).
  - largeTitleBackgroundColor / largeTitleHideShadow -> a separate
    `scrollEdgeAppearance`, built only when one of the two is set so screens
    without them keep sharing the one appearance object.
  - backTitleFontFamily / backTitleFontSize -> back bar button title
    attributes across every control state, including upstream's two quirks
    ('System' is not a customised family; a size with no family means BOLD).
  - blurEffect -> `UIBlurEffect` on the appearance.
  - disableBackButtonMenu -> a lazily built `UIBarButtonItem` subclass that
    swallows UIKit's back-menu assignment, the same mechanism as upstream's
    RNSBackBarButtonItem.
  - direction -> `semanticContentAttribute` on the navigation controller's
    view and bar.
  - experimental_userInterfaceStyle -> `overrideUserInterfaceStyle`.

Supporting changes:
  - `nativeColor` now parses `rgb()` / `rgba()`. This is the form
    react-navigation's own themes are written in, so without it every themed
    app's `titleColor` (and `color`, and `backgroundColor`) collapsed onto the
    fallback colour.
  - A named family plus an explicit weight resolves through the family's
    members by closest weight trait, mirroring `+[RCTFont updateFont:...]`
    (RCTFont.mm L478-491). Asking a font descriptor for the weight instead
    silently returned the regular face -- verified against upstream, which
    renders Georgia + '700' bold.
  - The appearance block is memoised on a signature of the fields it reads,
    keyed by the navigation controller's native hash. It runs many times per
    crossing, and rebuilding it every call cost ~400ms of cold-launch time and
    cut measured modal dwell from ~860ms to ~170ms. With the memo, splash is
    1195.8ms vs 1181.9ms for the control (n=8 interleaved pairs), modal dwell
    median 841.6ms vs 859.1ms, end_home_clean 8/8 both, stalled 0/8 both.

Verified in Release on sim BF759806 against nativescript-uikit-demo-original
(upstream RNS 4.25.2) driving the same temporary probe routes: the header
bands for the styled-title, large-title, blur and back-menu screens are
pixel-identical to upstream apart from the status-bar clock (~0.62% of
subpixels, constant across all four). Non-regression: descriptor header items
render and fire (Ping 2, Menu action 1), push/pop/present/dismiss clean, back
swipe pops and a short cancel swipe does not, pop shows outgoing content,
0 new .ips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gine-owned detached container (Wave 0 keystone)

A screen's React `headerRight` element now renders in the real UIKit
navigation bar, at its own size, and receives taps.

The element arrives on `headerConfig.children` (stock
`useHeaderConfigProps` wraps it in `ScreenStackHeaderRightView`). It is
pulled out on the JS side and mounted, by Fabric, into an engine-owned
`UIView` that is never attached to the view hierarchy. Once that
container reports it has children, it is wrapped in a
`UIBarButtonItem` customView and prepended to `rightBarButtonItems`.
Only the engine-owned container is ever moved, so no Fabric-managed
view is reparented.

Notes on the two things that actually made this work:

- `UIBarButtonItem` lays its custom view out with Auto Layout, and a
  plain `UIView` reports no intrinsic content size, so the bar
  collapsed the container to 36x0 and nothing rendered even though the
  item was installed. Real width/height constraints (kept in the
  registry, not as expandos on the native proxy) are what survive that
  layout pass; `onLayout` updates their constants so the item tracks
  the element's measured size.

- The bar-button signature memo now includes the slot revision.
  `hostReady` fires well after the controller is first configured, so
  without it the memo swallowed the install.

`headerConfig` is forwarded to the controller with `children` stripped
and memoized on a serializable signature, so React elements never reach
the host-props path and the identity churn react-navigation produces
every render no longer re-runs the native configure pass.

Verified in Release on the custom-stack demo: element renders at its own
size beside the descriptor Ping/Menu items, taps increment the React
counter (three taps, three increments), the item widens as its content
grows, it installs on cold launch without retries, and push / back-swipe
/ modal present / dismiss stay clean with no crash reports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(Wave 0 slots 2 and 3)

Generalizes the Wave 0 slot-hosting primitive from the single headerRight
slot to all three iOS header positions. A screen's `headerLeft`,
`headerTitle` and `headerRight` React elements are each mounted by Fabric
into their own engine-owned detached `UIView`, which the engine then installs
as a `UIBarButtonItem` custom view (left/right) or as
`navigationItem.titleView` (center). As before, only the engine-owned
container is ever moved — no Fabric-managed view is reparented.

- Slot storage is now keyed per (screen, side) via `headerSlotKey()`; the
  revision counter stays per screen so any slot change invalidates the
  bar-button memo.
- `configureHeaderTitleView` installs/clears the center container and tracks
  the last-installed view by CONTROLLER hash, since that map describes the
  native object's state rather than a screen's. It is called immediately
  before the `navigationItem.title` write in `configureScreenController`, and
  `applyHeaderSlots` re-writes the title after calling it — a title view
  assigned after the scalar title is not laid out on iOS 16, which is the
  same ordering upstream RNSScreenStackHeaderConfig.mm enforces.
- Left slots compose with the existing descriptor items and back-button
  logic: the hosted container leads `leftBarButtonItems` and descriptor items
  follow inboard of it, matching upstream's subviews-then-configs order.
  Screens with neither keep UIKit's native back button untouched.
- `findHeaderSlotElements` collects all three sides in one pass and rows up
  multiple elements per side, since `useHeaderConfigProps` emits one marker
  per `custom` entry in `unstable_header*Items`.
- `ScreenStackHeaderLeftView` / `ScreenStackHeaderCenterView` are now real
  marker components rather than null stubs.

Verified in Release on the simulator against the upstream RNS reference app:
the custom title renders centered at its own size with a header band that is
layout-identical to upstream; left and right hosted elements route taps and
resize live; screens without a left slot keep the native back button and
back-swipe. The blank-content-after-modal-cycles symptom observed during the
loop reproduces identically on the parent commit and is pre-existing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ll pass

`-[NativeScriptUIView layoutDetachedChildrenViewSubviewsAndReturnMutation]`
stamps `subview.frame = bounds` and a flexible autoresizing mask onto every
direct child of `_childrenView`. For a child under Auto Layout ownership
(`translatesAutoresizingMaskIntoConstraints == NO`) this both fights the
constraint-driven size on first layout AND — once the flexible mask is set —
makes UIKit's own `-[UIView(Geometry) _resizeWithOldSuperviewSize:]`
re-stretch the child on every subsequent superview bounds change (the
"decays after a few interactions" symptom).

The existing guards miss this: `preserveDetachedChildrenLayout` protects a
container's CHILDREN, not the container itself, and the direct-children loop
never consults `NativeScriptSubviewShouldFillParent`, so the `|origin| >= 1`
exemption never applies to a direct child. Constraint-pinning alone did not
help — the pass still overwrote the frame and the container oscillated.

Fix: in that loop, skip any subview whose
`translatesAutoresizingMaskIntoConstraints == NO` — write neither its frame
nor its mask. This is a minimal early-continue placed after the sentinel
skip and before both the `preserveDetachedChildrenLayout` guard and the
frame/mask writes, so behavior for TAMIC == YES subviews (all normal hosted
content) is byte-identical. Engine-owned slot containers set TAMIC = NO
precisely to claim this exemption.

Verified non-regression on the shared runtime: react-native-screens port
jest unchanged (409/411; the 2 failures are pre-existing flakes on an
untouched package), and the main-port-demo pop-wedge gate is identical
between a control at 8779df6 (25/25 completed, 0 wedges, median 102ms) and
this fix (25/25 completed, 0 wedges, median 98.9ms) on the same warmed host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lots

Replaces the `Passthrough` stubs for `ScreenFooter` and `FullWindowOverlay`
with real engine-owned UIKit slot hosting, built on the same `defineUIKit-
Container` + `attachController={false} attachNativeView={false}
emitOffWindowHostReady preserveDetachedChildrenLayout` primitive as the Wave 0
header slots. Both containers set `translatesAutoresizingMaskIntoConstraints
= NO` to claim the detached-children fill-pass exemption the TAMIC-skip
runtime fix creates, which is what keeps them from being stretched.

ScreenFooter (react-navigation `unstable_sheetFooter` / `FooterComponent`):
- The footer subtree is mounted into an engine-owned container installed as a
  bottom-pinned subview of the screen controller's own `view`, pinned
  leading/trailing/`safeAreaLayoutGuide.bottomAnchor` with a height constraint
  driven from the footer's measured `onLayout` height. Constraints are kept in
  the registry (never as expandos on NS proxies).
- `unstable_sheetFooter` is destructured out of `NativeScriptScreenStackItem`
  so the render function never reaches the serializable host-props path; the
  owning `screenId` reaches `ScreenFooter` through a React context that the
  stack item provides. Install is driven from BOTH the footer host's
  `hostReady` and `configureScreenController`, so either arrival order attaches.

FullWindowOverlay:
- The overlay's children are hosted in an engine-owned container added to the
  key window and pinned to its bounds with constraints; JS sizes the hosted
  subtree to the window dimensions. Pass-through is achieved by overriding
  `pointInside:withEvent:` via the runtime `__extendClass` (a plain, non-worklet
  nested function so it keeps `this`) so the container answers YES only over a
  hosted subview, combined with `pointerEvents="box-none"`.
- DOCUMENTED LIMITATION (not fixed): the overlay is hidden for the duration of
  a UIKit modal present and does not return after dismissal until it is
  remounted — upstream RNSFullWindowOverlay shares this; the real fix is a
  dedicated higher-level UIWindow this API cannot yet vend.

All new worklets keep helper-above-caller capture order and are fully
exception-safe in host-ready/dispose bodies.

Verified in a Release build of the custom-stack demo, driven via agent-device:
footer renders pinned at its own height and its taps route; the overlay covers
the whole window incl. the nav bar, its taps route, and empty-area taps pass
through to the content beneath; the detached-children stretch does NOT recur
across >10 push/pop interactions with the footer mounted; header slots, back-
swipe pop and modal present/dismiss stay clean; 0 new crash logs. The
react-native-screens port jest baseline (409/411, 2 pre-existing flakes) is
untouched, and the runtime TAMIC fix this builds on is committed separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ck + package unit/surface suites

Stand up host-side jest (no simulator/device) for
@nativescript/react-native-screens, borrowing the react-navigation repo's
installed toolchain (NativeScriptRuntime ships no jest). Three green suites:

- jest/native-stack.config.js — React Navigation's own native-stack suite with
  react-native-screens aliased to OUR package (19/19).
- jest/package.config.js — package unit suite (pure engine logic via a
  test-only __TEST_INTERNALS__ transform) + a react-native-screens
  public-surface behavioral suite (52/52).

Test-only mock for @nativescript/react-native (defineUIViewController /
defineUIKitContainer render children; runOnUI runs inline) plus a tabs stub and
an engine transformer that drops the worklets plugin and appends
__TEST_INTERNALS__. The engine source (src/NativeScriptScreenStack.tsx) is NOT
modified — no runtime behavior change.

Commands (from packages/react-native-screens, RN_NAV_REPO overrides the repo):
  npm test          # unit + surface
  npm run test:rnav # React Navigation native-stack

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement the five animated `stackAnimation` variants that until now fell
back to the UIKit default (`simple_push`, `slide_from_left`, `fade`,
`slide_from_bottom`, `fade_from_bottom`), driven by the `transitionDuration`
prop, via a single engine-owned `UIViewControllerAnimatedTransitioning`
vended from the stack's existing navigation delegate.

Plan A - upstream-shaped and wedge-proof:

- Extend (do NOT replace) the willShow/didShow nav delegate with
  `animationControllerForOperation:` (returns the custom animator ONLY for the
  five variants; nil for default/slide_from_right/ios_from_*/none/flip so every
  native path is byte-for-byte untouched) and
  `interactionControllerForAnimationController:`.
- The animator runs its motion through `UIView.animateWithDurationAnimationsCompletion`
  (the BOOL-arg block channel proven in this engine), NOT `UIViewPropertyAnimator`
  (its enum-arg completion is the marshalling failure that wedged prior attempts).
  Transforms / subview ordering / final frame mirror RNSScreenStackAnimator.mm;
  `fade_from_bottom` keeps the two-phase slide+fade with upstream's proportions.
- ONCE-guarded completion + a single bounded, never-re-arming watchdog
  (duration + 250ms) so a completion that never arrives snaps the final state
  and force-completes: worst case is a snapped frame, never a wedged stack.
- Engine-owned full-width `UIPanGestureRecognizer` + `UIPercentDrivenInteractiveTransition`
  for back-swipe survival under a custom animation, delegate-gated to
  (custom-animation AND gestureEnabled AND depth>1). The stock edge gesture yields
  to it for custom screens and is otherwise untouched.

New pure helpers (`stackAnimationUsesCustomAnimator`,
`customAnimatorDurationSeconds`, `stackAnimationIsInverted`,
`shouldOfferCustomInteractivePop`) are surfaced via `__TEST_INTERNALS__` and
covered by 10 new package unit tests (variant->animator selection, ms->seconds
duration mapping + fallback, interactive-controller gating).

Host-side only: `npm test` 62/62, `npm run test:rnav` 19/19. On-device
verification of the animations, completion firing, and interactive back-swipe
is pending (orchestrator).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s (statusBar / orientation / homeIndicator / hideKeyboardOnSwipe)

Previously these RNS props rode through `...rest` into `screenProps` and
were never consumed. Enforce them package-side, mirroring upstream
ios/RNSScreenWindowTraits.mm + RNSScreen.mm:

- Screens declaring a controller-level window trait (statusBarStyle /
  statusBarHidden / statusBarAnimation / screenOrientation /
  homeIndicatorHidden) are hosted by a UIViewController SUBCLASS built once
  via the runtime's `__extendClass` (`windowTraitScreenControllerClass`).
  Its getters — preferredStatusBarStyle / prefersStatusBarHidden /
  preferredStatusBarUpdateAnimation / supportedInterfaceOrientations /
  prefersHomeIndicatorAutoHidden — read the screen's live props from the
  registry (keyed by `restorationIdentifier`, which round-trips unlike a JS
  expando) and return the base UIKit default when a trait is unset. Screens
  declaring none keep the plain controller and are byte-identical.
- `updateWindowTraits` fires the UIKit re-query (setNeedsStatusBarAppearance
  Update / setNeedsUpdateOfHomeIndicatorAutoHidden /
  setNeedsUpdateOfSupportedInterfaceOrientations on the top-most presented
  controller) whenever the top screen is (re)configured. A stack is only
  "armed" once it has hosted a trait screen, so a trait-free stack issues no
  extra calls.
- `hideKeyboardOnSwipe` calls endEditing on the disappearing screen's view
  from the navigation-controller willShow closing branch (mirrors
  -[RNSScreen notifyWillDisappear]).

Pure, unit-tested mapping tables encode the RCTConvert(RNSScreen) /
RNSScreenWindowTraits raw-value tables (UIStatusBarStyle / UIStatusBar
Animation / UIInterfaceOrientationMask) plus the unset→base-default and
extend-on-demand predicates.

Feasibility: PACKAGE-ONLY. `defineUIViewController.createController` lets the
engine control screen-controller allocation, and `__extendClass` (napi
ClassBuilder) already overrides UIViewController property getters against the
metadata descriptors — no runtime change required.

Extended the unit suite with the mapping tables (18 tests). Host-side jest:
package unit+surface 70/70, React Nav native-stack 19/19. On-device
verification PENDING (status bar style/hide, orientation lock, home
indicator hide, keyboard dismiss on swipe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…SearchController)

Implement react-native-screens SearchBar support in the thin NativeScript stack
adapter. `UISearchController` is a native object, not a hosted React view, so
this takes the descriptor path (like the header appearance / bar-button items)
rather than the header-slot element-hosting path.

Engine (src/NativeScriptScreenStack.tsx):
- New search-bar worklet section above `configureScreenController`:
  `buildScreenSearchController` creates the `UISearchController` once per screen
  and wires a `UISearchBarDelegate` (via `ctx.delegate`) that routes
  begin/end-editing, text-change, search-button and cancel to `ctx.emit`;
  `applySearchBarProps` maps placeholder / autoCapitalize / barTint|tint|text
  colours (gated on presence so no accidental fallback) / cancelButtonText /
  obscureBackground / hideNavigationBar; `applySearchBarPlacement` sets
  `navigationItem.searchBarPlacement`, typeof-guarding the iOS 26 `integrated*`
  variants; `configureSearchBar` (called from `configureScreenController`)
  installs the controller, sets `hidesSearchBarWhenScrolling` from
  `hideWhenScrolling`, and is memoized by controller-hash + config signature
  (folded into the existing per-screen configure pass). Command hops
  (focus/blur/clearText/setText/cancelSearch/toggleCancelButton) resolve the
  controller from the registry by screen id.
- Registry gains `screenSearchControllers` / `screenSearchBarDelegates` /
  `screenSearchBarSignatures`; `clearScreenRecord` tears them down.
- Real `SearchBar` (forwardRef exposing the `SearchBarCommands` ref as `runOnUI`
  hops) and `ScreenStackHeaderSearchBarView` components; `findSearchBarElement`
  pulls the `<SearchBar>` out of the header children and `searchBarConfigFromProps`
  extracts the serializable subset. The item threads that config onto the
  sanitized header config, keeps live props in a ref for event dispatch, and
  mounts the element under a screen-id context so its command ref wires up.
  Search-free screens are left byte-identical (stable null / same config object).

Surface (src/index.ts):
- Export the real `SearchBar` / `ScreenStackHeaderSearchBarView` (were null
  stubs) and flip `isSearchBarAvailableForCurrentPlatform` to `true` — the
  switch that makes react-navigation send `headerSearchBarOptions`.

Tests: package suite 52 -> 69 (search config extraction, native configure +
memo + teardown, delegate->emit routing, command hops, colour gating,
placement guard, plus end-to-end mount + command-ref surface); native-stack
suite stays 19/19 with the flag now true (a `UISearchController` build no-ops
under jest since worklets never run there).

On-device verification PENDING (orchestrator): search bar renders, typing fires
onChangeText, focus/blur/cancel events, the six ref commands, and
hideWhenScrolling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… suite GREEN)

Adds `itest`, a self-asserting on-device net for @nativescript/react-native-screens
that drives the real custom-stack demo on the iOS Simulator and fails on
regressions (no more screenshot-only verification).

Three layers:
- L1 in-app driver (vendored under demo/app/itest/): a scenario registry that
  drives navigation via a shared navigationRef and publishes a machine-readable
  verdict into a ROOT-LEVEL ProbeOverlay accessibilityLabel. Root-level RN
  elements ARE exposed to `agent-device snapshot -i` (spike-confirmed).
- L2 host runner (lib/session.js): cold-launch per scenario via deep link, poll
  the probe label, fulfil AWAIT gestures (tapRef/tapXY/swipeBack), cross-check
  native/visual facts from the a11y tree.
- L3 aggregation (run.js): fresh-marker Release build + Hermes-bundle freshness
  proof, sim shutdown/boot to shed degradation, warm-up, bounded retries,
  per-scenario JSON + summary, non-zero exit on any FAIL.

core suite (already-landed behavior, all GREEN, exit 0):
nav-stack, modal, header-native, header-react, footer, overlay, tabs,
back-swipe. Not-yet-landed features are xfail stubs (anim-variants,
sheet-detents, searchbar).

Determinism: >=700ms cadence, 3.5s initial settle for the cold-launch content
pop-in, transition-idle gating + stable-state asserts (kills the rapid
same-route re-push reconcile flake), fresh cold launch per scenario, sim reset
per run. agent-device is pinned to the simulator UDID.

Demo-side files live in the non-git demo; vendored under demo/ with
sync-demo.sh (apply/capture/diff). See README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aits, search bar

Promotes the `anim-variants` / `searchbar` xfail stubs into real asserting
scenarios and adds the demo-side probe screens they drive, plus two new
scenarios (`anim-interactive`, `anim-frames`) and a reworked `statusbar`.

Demo (vendored under demo/, applied with sync-demo.sh):
- AnimFade / AnimFadeSlow / AnimNone / AnimSlideBottom routes, one per
  `stackAnimation` under test, each with an accessibility marker so the host can
  prove the pushed screen's CONTENT mounted (not just that route state flipped).
- Search route with `headerSearchBarOptions` (placeholder + onChange/onFocus
  publishing itest signals).
- StatusLight / StatusDark / StatusHidden, all sharing one dark full-bleed
  background so a screenshot of the top strip isolates the status bar itself.
- runner: a PASSING step's `detail` now rides out on the terminal DONE label, so
  a green run can report the numbers it asserted on.

Scenarios:
- anim-variants asserts BEHAVIORALLY, not by timing. Measuring the push and
  checking it tracks `animationDuration` does not work here: react-navigation's
  transitionStart/transitionEnd pair is delivered through the engine's reconcile
  and JS hops, and measured on-device those come out indistinguishable
  (default 833ms, fade(1800) 621ms, none 708ms, slide_from_bottom(900) 734ms).
  What IS crisp is which gesture path each variant gets — the engine offers its
  own full-width pan only for screens it hands to a custom animator — so the
  suite uses that as a decision procedure: a mid-screen drag pops `fade`, does
  nothing on `none` or on the default screen, and the stock EDGE drag still pops
  the default. The last two are the regression guard that default navigation
  never entered the custom-animator path.
- anim-frames captures in-flight frames of a 6s fade push: a mid-fade frame
  (incoming screen partly transparent over the outgoing one) is something
  neither UIKit's own push nor a completed transition can produce.
- statusbar pushes each variant FROM HOME and pops back. Chaining them was
  measured to leave the first variant's appearance in place for the rest, so a
  chained run cannot tell "trait applied" from "previous trait never replaced".
- searchbar is asserting but `xfail`: the search bar renders, but focusing it
  aborts the app (`setShowsCancelButton` selector unavailable). Promote it to
  the parity suite when that is fixed.

Host runner:
- snapshot parsing tolerates trailing state flags (`[editable]`, `[selected]`,
  ...). The old regex was anchored right after the quoted label and silently
  dropped every stateful node — including a UISearchBar's field.
- snapshotFull()/`full: true` host checks read the whole tree, for roles the
  `-i` interactive filter drops (a search field is role `search`).
- new gestures: `swipeWide` (full-width drag), `fillRef` (focus + type, falling
  back to the full tree), `screenshot|<name>` (capture a frame to results/shots).
- primeSnapshots(): after a fresh boot + rebind the first ~18s of snapshots come
  back without the app's tree, which left the FIRST scenario of every run blind
  for its opening steps. Poll until the tree is readable before scenario 0.
- sync-demo.sh: also turns on UIViewControllerBasedStatusBarAppearance (iOS
  ignores per-controller status-bar traits without it), and its capture no
  longer renames the exported ITEST_BUILD_MARKER binding while scrubbing the
  marker value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n confirms

On-device verification of the three post-core features found two of them broken
and one animation variant broken, so the asserting scenarios for those move to
`xfail` (still fully written, so they go green the moment the behavior is fixed)
and the `parity` suite keeps only what is confirmed working.

- anim-slide-bottom (NEW, xfail): `slide_from_bottom` pushes a screen whose
  content never mounts — route stack correct, nav-bar title correct, content
  area blank, marker never in the a11y tree (3/3 attempts, frame captured).
  `fade` and `none` mount the same probe screen fine, so it is specific to that
  variant. Split out of anim-variants so the working variants still gate.
- statusbar -> xfail: none of the window traits reach UIKit. Tested with each
  style paired against a CONTRADICTING background so iOS's own contrast
  adaptation cannot explain it, and with UIViewControllerBasedStatusBarAppearance
  confirmed YES in the installed app's Info.plist: 'dark' over dark stayed
  white, 'light' over light stayed dark, and statusBarHidden left the bar up.
  The appearance tracked the CONTENT, never the trait.
- searchbar -> xfail (already): crashes on focus.

Harness: a `screenshot|...` AWAIT is never re-fulfilled. The AWAIT deliberately
outlives the capture, so the retry was overwriting the correctly-timed frame
with one taken after the step had already advanced — which is what made the
first status-bar readings ambiguous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tors + on-simulator parity gate

Lands the six custom `stackAnimation` variants (a nav-delegate-vended
UIViewControllerAnimatedTransitioning with a force-complete watchdog and a
full-width interactive pan) and extends the on-simulator itest harness with a
`parity` suite that gates them.

Verified on simulator (iPhone 17, iOS 26, Release build, agent-device):
  itest core   8/8   (no regression)
  itest parity 3/3   (anim-variants, anim-interactive, anim-frames)
  npm test     62/62, npm run test:rnav 19/19

NOT included, and why (each has a fully-written asserting scenario parked in the
itest `xfail` suite so it goes green the moment it is fixed):
- searchbar: the search bar renders, but focusing it aborts the app —
  'Objective-C selector is not available: setShowsCancelButton' from
  searchBarTextDidBeginEditing. The guard is a typeof check that a NativeScript
  proxy satisfies for a selector it cannot dispatch; the real selector is
  setShowsCancelButton:animated:.
- statusbar (window traits): none of statusBarStyle/statusBarHidden reach UIKit.
  Tested with each style over a CONTRADICTING background so iOS's own contrast
  adaptation cannot explain it, with UIViewControllerBasedStatusBarAppearance
  confirmed YES in the installed app: the appearance tracked the CONTENT, never
  the declared trait.
- slide_from_bottom (a variant of the animator work that IS merged): pushes a
  screen whose content never mounts. Split into its own xfail scenario;
  fade / none / default are unaffected and gate normally.
…tent, not variant-specific

Re-ran `anim-slide-bottom` as the FIRST push of a cold launch (its own scenario,
rather than its old position inside anim-variants behind a fade push and pop):
the content mounts correctly and the host check passes, with the frame captured.

So the blank content is order-dependent, not a defect of that variant's
animator: it reproduced 3/3 only when the push followed an earlier push/pop in
the same launch, which points at the port's known content-mount timing (`fade`
and `none` drive the same probe screen). Stays `xfail` because a scenario that
depends on push order cannot gate; the comment now says what was actually
observed on both sides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… real bridged name

Focusing a header search bar aborted the app:

  Objective-C selector is not available: setShowsCancelButton
    at setSearchBarCancelButtonShown_… at searchBarTextDidBeginEditing

`setSearchBarCancelButtonShown` called `searchBar.setShowsCancelButton(flag,
true)` behind a `typeof searchBar.setShowsCancelButton === 'function'` guard.
That guard is worthless on a live native object — a NativeScript proxy answers
`typeof … === 'function'` for a selector it cannot dispatch — and the name+arity
pair it guarded does not exist: the real selector is
`-[UISearchBar setShowsCancelButton:animated:]`, bridged as
`setShowsCancelButtonAnimated` (packages/ios/types/UIKit.d.ts:19479).

Now calls `setShowsCancelButtonAnimated(flag, true)`, falling back to the
`showsCancelButton` property assignment (equivalent to animated:NO) for plain-JS
doubles/older bridges.

Audit of the other `typeof <nsProxy>.<selector> === 'function'` guards in the
engine (63 distinct selectors, each checked against the UIKit/Foundation
metadata typings): only one other name has no real bridged counterpart — the
`subview.pointInside(point, event)` fallback in the FullWindowOverlay hit-test
override (the real name is `pointInsideWithEvent`). It was unreachable on device
only because the preceding guard is always true on a proxy; removed so it cannot
resurface. Every other guarded name resolves.

Test double now answers only to `setShowsCancelButtonAnimated` and throws the
runtime's own error for the one-argument name, so a regression fails jest.
# Conflicts:
#	packages/react-native-screens/src/NativeScriptScreenStack.tsx
# Conflicts:
#	packages/react-native-screens/__tests__/unit/engine-pure.test.ts
…+ promote both itest scenarios)

Supersedes the WIP commit on this branch (temporary NSLog/pasteboard tracing,
removed here). Two independent defects kept every window trait from taking
effect; both are fixed and both are now gated on the simulator.

1. The overrides were never installed. `preferredStatusBarStyle`,
   `prefersStatusBarHidden`, `preferredStatusBarUpdateAnimation`,
   `supportedInterfaceOrientations` and `prefersHomeIndicatorAutoHidden` are
   metadata PROPERTIES, and `__extendClass` binds a property override only from
   an ACCESSOR descriptor — a plain function value is matched against method
   members only (ClassBuilder.mm `methodOverridesForName` skips every
   `member.property`). The screen-controller class was therefore extended with
   nothing at all. They are declared with `Object.defineProperty(…, { get })`
   now (the same shape the menuless back button's `menu` setter already used,
   for the same worklets-plugin reason).

2. Nothing carried UIKit's query down to the screen. With
   `UIViewControllerBasedStatusBarAppearance = YES`, iOS asks the KEY WINDOW'S
   ROOT controller and then only walks `childViewControllerForStatusBar{Style,
   Hidden}` / `childViewControllerForHomeIndicatorAutoHidden`. None of the
   containers answer those: not `UINavigationController`, and not React
   Native's root, which is a bare `[UIViewController new]`
   (RCTDefaultReactNativeFactoryDelegate.mm). Upstream solves this natively by
   swizzling those getters on `UIViewController` process-wide
   (ios/UIViewController+RNScreens.mm). This adapter is JS-only, so it builds a
   forwarding subclass with `__extendClass` and re-classes the controllers on
   the path with `object_setClass` (the mechanism KVO uses; the subclass adds no
   ivars — `objc_allocateClassPair(base, name, 0)` and ClassBuilder never calls
   `class_addIvar` — so the instance layout is unchanged).

   Engine-owned navigation controllers (the stack's and a presented modal's) are
   built from that subclass directly, so a modal screen's traits apply the same
   way. The forwarder hands UIKit the registry's current trait OWNER rather than
   the next hop down, because the measured chain is

     root(UIViewController) -> RNSTabsHostNativeScriptController
       -> RNSTabsScreenNativeScriptController
       -> …WindowTraitForwarder_UINavigationController
       -> …WindowTraitScreenController

   and the two tabs controllers are already `__extendClass`-extended, which the
   runtime refuses to extend twice; bridging the root alone is sufficient
   because UIKit recurses on whatever `childViewControllerFor…` returns. When no
   trait screen is visible the forwarder answers null — exactly what an
   untouched controller answers — so a trait-free app is unchanged.

   App-level requirement (documented in the engine and the itest README):
   `UIViewControllerBasedStatusBarAppearance` must be `YES`;
   `test/screens-itest/sync-demo.sh apply` patches the demo's Info.plist.

itest: `searchbar` and `statusbar` move from `xfail` into the asserting `parity`
suite. The status bar is system-drawn (invisible to both JS and the a11y tree),
so the harness gained FRAME CHECKS: `lib/png.js` decodes the captured frames and
`FRAME_CHECKS` asserts the clock band — light glyphs over a LIGHT background,
dark glyphs over a DARK background, an empty band when hidden, and the default
restored after the pop. Each style is paired with a contradicting background so
iOS's own contrast adaptation cannot produce a pass; the frames are deleted
before every attempt so a stale capture cannot satisfy a check.

`fillRef` no longer uses `agent-device fill`: tapping a UISearchBar activates
its UISearchController and re-numbers the tree, so the immediate type landed
before the field was first responder and delivered 0 `onChangeText` events. It
taps, lets the activation settle, then types.

On device (sim BF759806, Release, fresh-marker bundle, no Metro):
  parity 5/5 — searchbar `changes:7,text:nsrocks,focus:1`, statusbar all 5 frame
  checks ok; core 8/8; xfail 2 informational. jest 97, test:rnav 19.
…xed, both now gated

Two features that were held back after on-device verification now land, each
with an ASSERTING itest scenario promoted out of `xfail` into `parity`.

searchbar — focusing the header search bar aborted the app:
  'Objective-C selector is not available: setShowsCancelButton
     at setSearchBarCancelButtonShown_... at searchBarTextDidBeginEditing'
The engine called `setShowsCancelButton(flag, animated)` behind a
`typeof searchBar.setShowsCancelButton === 'function'` guard, which is worthless
on a live native object (a NativeScript proxy answers 'function' for a selector
it cannot dispatch). The real selector is `-[UISearchBar setShowsCancelButton:
animated:]`, bridged as `setShowsCancelButtonAnimated`. All 63 `typeof`-guarded
selectors in the engine were audited against the UIKit/Foundation metadata; the
only other name with no real counterpart was the dead `subview.pointInside(...)`
fallback in the FullWindowOverlay hit-test (real name `pointInsideWithEvent`),
removed so it cannot resurface.

statusbar — no window trait reached UIKit, for two independent reasons: the
overrides were passed as plain functions where `__extendClass` only binds
property overrides from accessor descriptors (so nothing was installed at all),
and nothing forwarded UIKit's root-down query to the screen (RN's root is a bare
`[UIViewController new]`; neither it nor a `UINavigationController` answers
`childViewControllerFor...`). The engine now installs accessor overrides and
re-classes the window root into a forwarding subclass — the JS-only analogue of
upstream's `UIViewController+RNScreens` swizzle — with engine-owned navigation
controllers (stack and presented modal) built from that subclass.

Gates on this merge (sim BF759806, Release, fresh-marker bundle, no Metro):
  ./itest run --suite parity  5/5  (anim-variants, anim-interactive, anim-frames,
                                    searchbar, statusbar)
  ./itest run --suite core    8/8
  ./itest run --suite xfail   2 informational (anim-slide-bottom, sheet-detents)
  npm test 97/97 . npm run test:rnav 19/19

The statusbar scenario is gated on PIXELS (new `FRAME_CHECKS` + `lib/png.js`):
the status bar is system-drawn and invisible to both JS and the accessibility
tree, so each style is asserted from the captured frame over a CONTRADICTING
background — white glyphs over a light background, dark glyphs over a dark one,
an empty band when hidden, and the default restored after the pop.
…ld push no longer lost)

`shouldDelayPushUntilControllerHasContent` held every animated push back for
TWO reconciles unconditionally before it ever consulted its probe, then for up
to two more when the probe reported no content. Both halves are now measured to
be wrong:

* The ordering it assumed does not exist. Instrumented over 58 cold cycles,
  `t(contentReady) - t(push)` was NEGATIVE on all 8 measurable pushes
  (-33ms..-264ms): a screen's content is hosted BEFORE the push is classified,
  not ~270ms after it.
* The probe cannot decide anything on the push path. `viewHasHostedReactContent`
  answers true for any non-hidden subview at depth > 0, and a pushed
  controller's chain (`controller.view -> children container -> ActivityView ->
  contentView`) always has one, so it has never returned false for a pushed
  screen.

What the wait did cost, measured on lane 1 as JS `navigate()` -> UIKit
`viewControllers` mutation over 12 interleaved cold pairs of the same demo
build:

  with the wait     : 2/12 cycles installed the push at all (median 775ms);
                      on the other 10 the push was DROPPED outright
  without the wait  : 12/12 installed (median 635ms), all via the animated
                      push branch

The drop is the interesting half. On a cold-launch first navigation the window
in which the pushed controller is resolvable is often exactly ONE reconcile
wide -- the very next reconcile already reports `req=2 avail=1`. Burning that
one reconcile on an unconditional wait loses the navigation completely, which
is also why most first navigations were observed arriving through the
non-animated `setViewControllers` tail rather than the animated push.

No blank appeared in its place: 6 interleaved pairs scored with the frame-exact
rig read CUM detail_blank = 0.0 on BOTH builds, and the pushed Detail screen is
fully rendered in the push animation's own frames.

`controllerHasReactContent` / `viewHasHostedReactContent` are KEPT --
`reconcilePresentedModalStack` still uses the readiness notion for the
modal-present path, where content genuinely does land after presentation.

Gates: npm test 97/97, test:rnav 19/19, itest core 8/8, itest parity 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pplyStackModel

All mutation of a stack's `viewControllers`, and all model bookkeeping that
describes it, now flows through one worklet:

  applyStackModel(stackId, registry, ctx, targetIds, { animated, reason, token })

Before this the engine had SEVEN install sites (reconcile, the dismiss-repair
runOnUI hop, the transition fallback, the modal-dismiss base restore, the modal
base-sync, the modal-sync, and the did-show fallback). Each carried its own
hand-rolled push/pop/replace classification, its own ~13 scattered
`stackNativeKeys`/`stackNativeCounts` writes, and its own copy of the same
`layoutNavigationStackViews -> configureStackControllers ->
configureNavigationAppearance -> updateNativeBackGesture` tail (repeated ~5x).

Consolidation:

* `applyStackModel` owns the classification. `reconcile` is the only reason
  that classifies (push / pop / replace / plain-set + same-model no-op); the
  other six reasons are already-decided repairs that take the plain-set path
  and differ only in their tail. `animateStackPush/Pop/Replace` and
  `setNavigationControllerViewControllers` are now private primitives of the
  funnel; the sole exemption is `createNavigationController`, which seeds a
  brand-new (not-yet-presented) controller at construction time.
* `recordStackModel` is the SINGLE writer of `stackNativeKeys`/`Counts`,
  replacing the ~13 scattered pairs.
* `configureStackAfterInstall` is the one post-install tail.
* New `registry.stackModelVersions`: `recordStackModel` bumps it on every
  accepted apply. Timer-scheduled callers (transition fallback, did-show
  fallback) capture the version when armed and pass it as `token`; the funnel
  DROPS a stale-token call unless native ids still disagree with the target AND
  no newer model superseded it. The blind re-install timers become no-ops once
  a newer model has landed.

Behavior-preserving: every path taken today issues byte-identical UIKit calls.
The animated push/pop/replace branches keep their exact `markTransition` /
`scheduleTransitionFallback` bookkeeping (the funnel returns the transition
descriptor and reconcileStack arms the fallback, so the funnel does not have to
capture a helper declared below it).

The engine file grows ~73 code lines net (+169 total, most of it the funnel's
doc block and the stale-token machinery) -- the win is structural: 7 install
sites -> 1, ~13 model writers -> 1, ~5 duplicated tails -> 1.

Also: fix a pre-existing itest-harness eagerness bug the funnel work surfaced.
`fulfillGesture`'s `fillRef` tapped a search-field ref whose stored rect came
from the poll snapshot at the instant the AWAIT label appeared -- before the
UISearchBar's accessibility frame settled -- so the tap landed at the bar's
origin corner (38,84) instead of the field centre (201,139) and nothing was
typed (0 onChangeText). It now re-resolves the ref from a fresh snapshot right
before the tap. This is unrelated to the engine change (the searchbar itest
failed identically on the parent commit) and restores parity 5/5.

50/58 finding: the earlier "most first navigations never take the animated push
branch" was the dead content-wait removed in the previous commit, not a
classification gap. Instrumented A/B: with the wait gone the first push-detail
installs via the animated `pushViewControllerAnimated` branch on 12/12 cold
cycles; the only non-animated tail on cold start is the initial single-screen
Home seed (previousKey null), which is correct and byte-identical.

Adds applyStackModel unit tests (classification, stale-token drop, single-writer
bookkeeping).

Gates: npm test 106/106, test:rnav 19/19, itest core 8/8, itest parity 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hine (Stage 2)

Replace the modal boolean sprawl (stackModalPresentedModally,
stackModalDismissRequestedFromJS) with a coherent, epoch-guarded state
machine that advances on OBSERVED UIKit state rather than depending on the
present/dismiss completion firing. Behavior-neutral: modal still maps to
OverFullScreen; the mapping flip is a later stage.

Registry: stackModalPhase (idle | present-requested | presenting | presented
| dismiss-requested | dismissing), stackModalEpoch (bumped on every phase
entry; completions/probes/drains no-op on mismatch), stackModalQueued (the one
latest-wins queued dismiss), plus desire / drain-armed / observed-streak /
observed-taken / suppress-emit.

- Present boundary: reconcileStack records the modal desire (from
  firstModalIndex) ABOVE the transition gate and arms requestModalDrain (a
  coalesced dispatch_async(main) hop); it no longer presents/dismisses inline.
- modalMachineAction: the pure transition table (single source of truth;
  dismiss is returned only from `presented`).
- drainModalStack: the sole caller of presentModalStack/dismissModalStack;
  never mutates viewControllers (all installs go through applyStackModel);
  record-on-present moved to the presenting edge.
- Drain gate (hostPresentationBlocked): refuses to present while the host is
  mid-transition / not in a window; parked with NO timer re-arm. Liveness =
  boundary drains from finishTransition, did-show, and every reconcile.
- Observation fan: bounded fixed one-shot setTimeout fan, epoch-guarded,
  read-only; drives observed-presented (stable 2 probes) / observed-gone /
  observed-wedged(never-taken safe-drop). The completions are accelerators.
- Deleted the destructive dismiss watchdogs (setTimeout(complete,420/700) +
  force-hide/removeFromSuperview). Safe teardown: finalize only when observed
  completed; drop (registry/delegate clear + finishTransition(cancelled)) when
  never taken — never touch the modal view's superview (UIKit owns it).

Unit suite +14 (transition table, epoch bump/no-op, single dismiss issue
point, desire recorder, present gate): 120/120. rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Retire the false-positive `controllerHasReactContent` probe from the modal
present gate and wait on real native content-readiness instead.

A modal screen's children are hosted while its controller is still off-window
(it is not presented yet), so the previous view-tree probe could never tell an
empty modal controller from a ready one -- any non-hidden subview in the bare
`ActivityView` wrapper chain satisfied it, so it never once returned false.

Stage 3 replaces it with the native `hostReady` signal:

  * `NativeScriptScreenController` gains a guarded `hostReady` handler that sets
    `registry.screenContentReady[screenId]` once the screen reports mounted
    children, and — only while its parent stack is mid-modal — re-drives the
    parked present via `requestModalDrain` (readiness arriving is a drain
    event).
  * Modal screens carry `emitOffWindowHostReady`, so that signal reaches them
    BEFORE they are ever presented. Plain pushed screens do not opt in, so
    their hosting timing is unchanged.
  * The present gate consults `screenContentReady` through the new pure
    `modalPresentReadinessAction`: present when ready, else HOLD for exactly one
    reconcile of grace (the existing `MODAL_CONTENT_RETRY_LIMIT` deadline) then
    present regardless. The bound is what prevents the documented deadlock (an
    unpresented modal never lands content, so readiness never arrives).

The dead view-probe chain (`controllerHasReactContent` /
`viewHasHostedReactContent` / `isEngineBookkeepingView`) is removed.

Mapping stays OverFullScreen (Stage 4 flips it). Host-side gates green:
`npm test` 123/123 (+3 readiness), `npm run test:rnav` 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…it sheets (Stage 4)

Flip `modalPresentationStyle` to the upstream-parity mapping now the modal
state machine survives the present/dismiss race that reverted `60924dac`:

  * `modal`     -> UIModalPresentationAutomatic (page sheet on iPhone)
  * `pageSheet` -> UIModalPresentationPageSheet
  * `formSheet` -> UIModalPresentationFormSheet (unchanged)

`fullScreenModal` deliberately STAYS OverFullScreen: true FullScreen detaches
the RN root surface and kills touch on the presented screen (documented at the
branch). `transparentModal` / `containedTransparentModal` stay non-opaque.

Restore the iOS 18 `sheetPresentationController.prefersPageSizing` write that
the `3ff911a5` revert removed (typeof-guarded so an older OS is left alone).

What makes the sheet survive rapid present->dismiss WITHOUT a destructive
watchdog is the already-landed machine: the `hostPresentationBlocked` drain
gate only presents from an idle boundary, an early dismiss is CANCELLED
(present-requested) or QUEUED latest-wins (presenting) instead of racing a live
presentation, and teardown runs only against an observed-completed present.

Host-side gates green: `npm test` 123/123, `npm run test:rnav` 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the non-vacuous on-simulator gate for the page-sheet lifecycle, plus the
host-side automation a presented modal requires (it removes the ProbeOverlay
from the accessibility tree, so the app's AWAIT handshake is unreadable — the
host drives captures and swipes autonomously off the modal's content label).

New core scenarios (demo app/itest/scenarios.tsx; catalog lib/suites.js):
  * page-sheet-visible — the modal presents INSET from the top AND showing
    content, proven in TWO frames captured >=300ms apart (host auto-capture +
    FRAME_CHECKS: top-band dimmed ~198 vs the OverFullScreen white 247, body
    spread proves content, not stuck-blank chrome). Distinguishes a real sheet
    from both OverFullScreen and the old stranded-blank failure.
  * swipe-down-syncs — a host swipe-down dismisses the sheet and collapses JS
    routes to [Home] (presentationControllerDidDismiss -> onDismissed -> pop),
    Home stays interactive; a second modal with gestureEnabled:false SURVIVES
    the identical swipe (new ModalNoGesture route + itest modal marker).
  * rapid-present-dismiss — navigate('Modal') then goBack() at 200/300/400ms
    x3, asserting a clean [Home] after each (the race that stranded 60924da).

The existing `modal` scenario now asserts sheet-inset (modal-sheet frame check)
instead of full-screen. Harness additions (lib/host-checks.js, lib/session.js):
AUTO_CAPTURE, AUTO_GESTURE (rising-edge, settle-gated), a swipeDown gesture
verb, and custom named-region frame stats.

On-sim results (Release, sim BF759806, no Metro): core 11/11 (all attempt=1),
parity 5/5 unregressed, end_home_clean 27/27 (100%) over 9 rapid launches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant